Skip to content

Go 项目结构:让目录边界和依赖方向说同一件事 ​

一个订单接口修改价格计算,结果要同时改 handler、service、manager、repository 和 util,目录再整齐也难维护。本文要解决的是:哪些代码放在一起,哪些依赖可以跨过去,以及项目长大时从哪里拆。cmd、internal、pkg 会用到,但它们各自有不同的地位,不能当作必须集齐的文件夹。

示例从一个只有三个包的订单预览程序开始。它不连接数据库,也不伪装成完整电商服务;这样可以直接用实际 import、运行输出和编译失败看清结构。

先区分工具强制的规则和团队惯例 ​

名称或机制地位用来解决什么
package main 与 func mainGo 程序入口规则生成可执行程序
internal/go 命令强制的导入限制防止目录树以外的代码依赖内部实现
首字母大写语言的导出规则决定别的包可访问哪些标识符
cmd/<程序名>/常见组织方式一个模块有多个命令时区分入口
pkg/普通目录名有些团队用它放公开包,没有额外访问权限
service/、domain/ 等设计选择表达本项目的责任边界,不是 Go 指令

官方布局指南同时给出简单命令、简单包、多个包和多个命令的组织方式,并未规定所有仓库使用同一套目录。布局指南、导出规则

尤其要区分“大写”和“可导入”:internal 内的 Order 即使大写,外部代码也必须先通过 internal 导入规则。pkg/secret 里的 Secret 则不会因为你在团队文档中说“内部用”而自动禁止外部导入。internal 规则

三个包足以解释一个可工作的结构 ​

本例文件布局:

text
shop/
  go.mod
  cmd/preview/main.go
  internal/httpapi/handler.go
  internal/order/order.go
  • order 只知道订单数量与单价如何计算,不知道 HTTP。
  • httpapi 把一次 HTTP 调用变成订单计算,再把结果写到响应。
  • preview 组装 handler,并用标准库的 HTTP 测试工具发起一次进程内调用。

这里箭头表示 import 依赖,不是 HTTP 请求传播方向:

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    C["cmd/preview<br/>组装与运行"] --> H["internal/httpapi<br/>HTTP 适配"]
    H --> O["internal/order<br/>订单规则"]
    O --> E["标准库 errors"]
    H --> N["标准库 net/http"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    C["cmd/preview<br/>组装与运行"] --> H["internal/httpapi<br/>HTTP 适配"]
    H --> O["internal/order<br/>订单规则"]
    O --> E["标准库 errors"]
    H --> N["标准库 net/http"]

看 order 包中的关键方法:

go
func (o Order) Total(unit int) (int, error) {
    if o.Items <= 0 || unit < 0 {
        return 0, errors.New("invalid order")
    }
    return o.Items * unit, nil
}

handler 创建 Order{Items: 2} 并以单价 500 调用它。在 shop 下执行:

bash
go run ./cmd/preview
text
status=200 total=1000

这段计算只处理教学用小整数,没有处理整数乘法溢出、币种或金额精度,不能直接当作生产计价系统。代码的用途是展示:业务计算不依赖 HTTP 类型,因此后续可以被 CLI、后台任务或单元测试复用。这个结构选择属于设计建议,而计算结果来自附录程序。

用工具核对图是否真实:

bash
go list -f '{{.ImportPath}} -> {{join .Imports ", "}}' ./...

本次输出中,订单包一行是:

text
example.com/shop/internal/order -> errors

如果这行后来出现 net/http,就值得检查订单规则是否开始处理传输协议;如果出现自己的 httpapi,则已经形成反向依赖。go list 返回包的导入信息,适合用于检查结构,而不只是画一张漂亮的架构图。go list

internal 的墙画在哪里 ​

对 a/b/internal/c,可导入范围由 internal 的父目录 a/b 所覆盖的树决定。模块模式下,go 命令还按相应导入路径规则执行检查;重点不是“同一个仓库”或“同一个 Git 组织”,而是这个边界。go 命令说明、Go 1.27.2 检查实现

本例再建一个独立模块 example.com/outsider,用 replace 指向本地 shop,然后尝试:

go
import "example.com/shop/internal/order"

实际编译错误:

text
use of internal package example.com/shop/internal/order not allowed

replace 让编译器找到了源码,不能绕过 internal。这个实验比“目录叫 internal 就是私有的”更准确:它是包导入边界,不是文件加密、操作系统权限,也不会防止别人阅读仓库源码。

若已有多个命令应共享实现,把共享部分放在模块根下的 internal 很合适。若某个包确实要给外部使用,就放在非 internal 路径,并把导出 API 当作兼容承诺审查。没有必要为了“可能有人复用”就提前把所有内部类型公开。

按领域还是按层:看修改会沿着哪里走 ​

下面两种组织都能编译,差别是代码以什么关系聚在一起。

组织方式典型路径比较适合主要代价
按技术层handler/、service/、repository/功能很少,层内规则相近,团队容易看懂同一业务修改横跨多个大包;容易出现万能 service
按领域能力order/、billing/、catalog/不同业务有自己的词汇、规则和变化节奏要认真约束领域间调用,否则只是把耦合搬了位置
领域为主,内部再分适配order/、orderhttp/、orderstore/业务逻辑已有规模,外部协议和存储变化独立多包带来接口、数据转换与导航成本

这些是本文针对维护成本的判断,不是“领域分包永远更好”的语言结论。Go 关于包命名的官方建议强调包名要表达用途,避免让 util、common、misc 成为无边界的杂物筐。包名建议

例如,税率、折扣与订单状态会一起变化,可以先在 order 包中相互调用;PostgreSQL 的连接串、SQL 错误码和重试策略则属于另一类变化。是否把后者拆成 orderstore,取决于代码规模和测试需要。不要一开始就为每个业务名词创建三层、一套 DTO、一个接口和一份工厂。

相反,看到重复的跨业务调用也别马上扔到 common。先问清共同的是稳定概念,还是今天碰巧同名的字段。“用户地址”和“仓库地址”未必有同样的校验规则;过早共享会让以后两者的变化互相牵制。

循环依赖是具体的编译错误,也可能是责任混在一起的信号 ​

Go 禁止包直接或间接导入自己。附录让 a 导入 b、b 再导入 a,实际得到:

text
imports example.com/cycle/b from a.go
imports example.com/cycle/a from b.go: import cycle not allowed

这是语言级约束,不是 lint 偏好。包导入

常见修复有三个方向,先选能说清责任的那个:

  1. 两包本来就共同维护同一个不变量,可以合并,减少人为边界。
  2. 提取真正稳定、双方都需要的底层类型或规则;新包必须有清楚职责,不能只是改名的 common。
  3. 消费方定义一个小接口,让上层在入口处注入实现,避免业务包导入具体适配器。接口应来自实际使用需求,不用为每个结构体预建一个镜像接口。接口审查惯例

举例说,order 需要“保存订单”,但不需要知道 SQL 连接池类型。它可以定义保存能力,数据库适配器实现该能力,由 cmd 组装;数据库适配器依赖订单类型,订单包不再反过来依赖数据库包。这是实际减少依赖边的做法,具体构造和资源释放另见《Go 依赖注入》。

什么时候值得多建一个包 ​

本文建议用三个具体问题检查拆包,而不按文件行数机械切割:

  • 它有没有自己的稳定词汇和规则?一个 ParseMoney 与订单状态机不一定需要同一包。
  • 它是否独立变化或需要独立测试?HTTP 与定时任务共用计算时,拆开传输层通常有帮助。
  • 拆完以后依赖图是否更简单?如果每次改一个字段都要新增三份转换代码,先检查是不是切得太细。

小命令可以从根目录一个 main 包开始;多个命令再引入 cmd;内部实现长大再按职责拆 internal。官方布局指南也按程序复杂度逐步引入目录。避免过早分层的意思是保留合适的演进空间,不是等到出现巨型包才允许整理。官方布局示例

资料与实验范围 ​

2026-10-13 在 Go 1.27.2、Linux/amd64 运行成功程序、列出实际 imports、执行 vet,并验证两种故意失败:跨 internal 边界、循环导入。go test ./... 在成功示例中只验证包可编译,输出 [no test files],不能把它写成“业务测试通过”。未部署 HTTP 服务。

完整复现文件 ​

按以下路径保存,设置 GOTOOLCHAIN=local、GOFLAGS=-buildvcs=false,执行 bash run_layout.sh。两个失败模块单独放置,避免正常包检查被故意失败的例子污染。

run_layout.sh ​

bash
#!/usr/bin/env bash
set -eu
cd "$(dirname "$0")/layout/shop"
echo '== allowed internal import =='
go run ./cmd/preview
go list -f '{{.ImportPath}} -> {{join .Imports ", "}}' ./...
go test ./...
go vet ./...
cd ../outsider
echo '== forbidden internal import: expected failure =='
if go build .; then exit 1; else echo expected_failure=true; fi
cd ../cycle
echo '== import cycle: expected failure =='
if go test ./...; then exit 1; else echo expected_failure=true; fi

layout/cycle/a/a.go ​

go
package a

import _ "example.com/cycle/b"

layout/cycle/b/b.go ​

go
package b

import _ "example.com/cycle/a"

layout/cycle/go.mod ​

go.mod
module example.com/cycle

go 1.27.2

layout/outsider/go.mod ​

go.mod
module example.com/outsider

go 1.27.2

require example.com/shop v0.0.0
replace example.com/shop => ../shop

layout/outsider/main.go ​

go
package main

import "example.com/shop/internal/order"

func main() { _ = order.Order{} }

layout/shop/cmd/preview/main.go ​

go
package main

import (
	"example.com/shop/internal/httpapi"
	"fmt"
	"net/http/httptest"
)

func main() {
	rec := httptest.NewRecorder()
	httpapi.NewHandler().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
	fmt.Printf("status=%d %s", rec.Code, rec.Body.String())
}

layout/shop/go.mod ​

go.mod
module example.com/shop

go 1.27.2

layout/shop/internal/httpapi/handler.go ​

go
package httpapi

import (
	"example.com/shop/internal/order"
	"fmt"
	"net/http"
)

func NewHandler() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		total, err := (order.Order{Items: 2}).Total(500)
		if err != nil {
			http.Error(w, "invalid order", http.StatusBadRequest)
			return
		}
		fmt.Fprintf(w, "total=%d\n", total)
	})
}

layout/shop/internal/order/order.go ​

go
package order

import "errors"

type Order struct{ Items int }

func (o Order) Total(unit int) (int, error) {
	if o.Items <= 0 || unit < 0 {
		return 0, errors.New("invalid order")
	}
	return o.Items * unit, nil
}

保留各书来源与许可 · 示例运行条件以正文为准